Skip to content

feat(billing): launch Pro and Team plans with single-seat subscriptions#705

Merged
softmarshmallow merged 18 commits into
mainfrom
canary
May 7, 2026
Merged

feat(billing): launch Pro and Team plans with single-seat subscriptions#705
softmarshmallow merged 18 commits into
mainfrom
canary

Conversation

@softmarshmallow

@softmarshmallow softmarshmallow commented May 4, 2026

Copy link
Copy Markdown
Member

Summary

Grida now has a working paid-plan billing system. Customers can subscribe to Pro or Team, manage their subscription themselves, and receive invoices — all backed by Stripe.

This is v1. Multi-seat billing is intentionally deferred — see "Known limitations" below.

What this delivers

  • Pro and Team plans are buyable — self-serve Stripe Checkout from the billing page, monthly or annual (annual is 20% off).
  • Single-seat subscriptions — every paid sub starts and stays at quantity: 1. Stripe bills the flat per-plan price ($20/mo Pro, $60/mo Team; $192/yr Pro, $576/yr Team).
  • Customer Portal flow_data deep-links — customers cancel, change card, and switch plan or interval through dedicated single-screen Portal flows. Plan/interval changes bill immediately for the prorated difference. The generic Portal dashboard is never exposed.
  • Past-due and dispute handling — past-due / unpaid / incomplete subs surface a "Update payment method" prompt and disable plan changes. Disputed charges suspend the subscription; resolved disputes restore it; lost disputes cancel.
  • Stripe webhook is the sole source of truth — signature-verified, idempotent (stripe_event PK + ON CONFLICT DO NOTHING), projected into grida_billing.* via a single PL/pgSQL function. No path mutates billing state outside the projector.

Architecture highlights

  • editor/lib/billing/index.ts is the only allowed import site for the Stripe SDK; oxlint enforces this with no-restricted-imports.
  • grida_billing is a locked-down schema (REVOKEd from authenticated/anon); the editor reads through public.v_billing_* views with RLS.
  • Billing reads use the user-authed Supabase client; mutations go through service_role.workspace.* inline at every callsite (no aliasing — keeps grep service_role honest).
  • Test infra: webhook E2E suite gated by BILLING_E2E=1 (refuses to run without sk_test_* and BILLING_TEST_MODE=true); 37 pgTAP scenarios cover RLS, projector branches, idempotency, and annual catalogue resolution.

Known limitations (v1)

  • Multi-seat billing is not wired. Org members can be invited and removed normally, but subscription.quantity stays at 1 — Stripe bills the flat plan price regardless of member count. Tracked: GRIDA-60. The seat-sync triggers were dropped before launch because they mutated local state without telling Stripe.
  • getBillingSummary makes one Stripe API call per page load to derive the current interval (Stripe's only source for it). Acceptable for v1; future work projects interval onto the local subscription row from the webhook.
  • No reconciler. v1 trusts Stripe→us via the webhook; us→Stripe currently relies on synchronous calls in server actions with no durable intent layer. Adding the outbox is part of the multi-seat work above.

Test plan

  • New user signs up → lands on Free
  • Upgrade from /organizations/<org>/settings/billing/upgrade (monthly) → Stripe Checkout → returns to billing page → plan flips to Pro within seconds
  • Toggle Annual on the upgrade page → Pro Annual at $192/yr (20% off sticker)
  • Switch Pro → Team via the upgrade modal → Customer Portal subscription_update_confirm flow → charged prorated difference → dashboard updates
  • Switch Pro Monthly → Pro Annual via the upgrade modal → same flow, immediate prorated invoice
  • Cancel from billing page → "Cancel at period end" Portal flow → status shows cancels at period end
  • Update card → Portal payment_method_update flow → return to billing page
  • Trigger failed-payment with test card 4000 0000 0000 0002 → past-due banner + "Update payment method" CTA, plan-change buttons disabled
  • Adding a teammate to a paid org does not change the seat count or trigger a Stripe invoice (known limitation; see GRIDA-60)

Summary by CodeRabbit

  • New Features

    • Full billing experience: org billing settings, upgrade modal/flows, invoices, payment methods, Stripe webhook receiver, and org-level plan display across the app.
    • Pricing pages default to monthly and updated pricing rows.
  • Documentation

    • New platform billing guide, contributor setup, pricing FAQ, and multiple billing test/spec documents.
  • Tests

    • Billing E2E suite with fixtures and idempotency/lifecycle/tampered-signature scenarios; committed deterministic test env defaults.
  • Chores

    • DB billing schema & migrations added and local provisioning scripts included.

- Add user-facing billing doc at docs/platform/billing.mdx covering plans, AI credits, top-ups, $0.25 floor, per-seat Pro, and team credit pooling.
- Add FAQ section to /pricing linking to the billing guide.
- Remove legacy/dishonest features (Advanced Analytics, Google Sheets, Notion, Toss, Ticketing for Events, KakaoTalk, WhatsApp, Credits Rollover); drop Ticketing category and move Simulator under Forms.
- Rename "Generated Image License" -> "Generated Media License"; gate "Buy extra credits" from Pro.
- Reorder comparison table so Support is the last section.
@vercel

vercel Bot commented May 4, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
blog Ready Ready Preview, Comment May 7, 2026 0:26am
docs Ready Ready Preview, Comment May 7, 2026 0:26am
grida Ready Ready Preview, Comment May 7, 2026 0:26am
viewer Ready Ready Preview, Comment May 7, 2026 0:26am
3 Skipped Deployments
Project Deployment Actions Updated (UTC)
backgrounds Ignored Ignored Preview May 7, 2026 0:26am
code Ignored Ignored May 7, 2026 0:26am
legacy Ignored Ignored May 7, 2026 0:26am

Request Review

@coderabbitai

coderabbitai Bot commented May 4, 2026

Copy link
Copy Markdown

Walkthrough

Adds a Stripe-backed billing subsystem: DB schema and migrations, public wrapper functions and pgTAP tests, server-side billing library and webhook receiver, org-scoped billing UI/pages and server actions, E2E fixtures/tests, pricing/marketing updates, tooling/config, and contributor docs.

Changes

Stripe Billing System

Layer / File(s) Summary
Data Shape
supabase/schemas/grida_billing.sql, supabase/migrations/20260506132900_grida_billing.sql, supabase/migrations/20260506132901_drop_legacy_pricing_tier.sql, database/database-generated.types.ts
Add grida_billing schema (account, subscription, product_catalogue, stripe_event, audit), seed catalogue, RLS, triggers, projector functions; add public wrapper views/functions (v_billing_subscription, v_billing_audit, fn_billing_*); add is_enterprise on organization and drop legacy pricing_tier/display_plan; update generated DB types.
Core Implementation
editor/lib/billing/index.ts, editor/scripts/billing/setup-stripe-test.ts, editor/lib/supabase/server.ts, editor/package.json
New billing core: Stripe client init and proxy, BillingError, auth/assert helpers, RPC wrappers (customer/catalogue/subscription), event dispatch/enrichment, customer resolve/create, and idempotent Stripe test provisioning script; add stripe dependency and service_role docs.
API / Webhook Wiring
editor/app/(api)/private/webhooks/stripe/route.ts, public.fn_billing_apply_stripe_event (SQL)
Add Next.js POST webhook route that verifies Stripe signatures, constructs events, dispatches to billing RPC, stamps failures on error; runtime nodejs and force-dynamic.
Server Billing Actions
editor/app/(site)/organizations/[organization_name]/settings/billing/_actions.ts
Server actions: getBillingSummary, listInvoices, startPaymentMethodUpdate, startPlanChangeConfirm, startSubscribeCheckout, startCancelSubscription, resumeSubscription, listBillingAudit — integrates Supabase RPCs, Stripe portal/checkout flows, caching, and access checks.
UI Components & Pages
editor/app/(site)/organizations/[organization_name]/settings/billing/_view.tsx, .../upgrade/_view.tsx, _modal-shell.tsx, page.tsx, @modal/*, layout.tsx
Add BillingView, UpgradeView, modal intercepts, layout and pages for org billing/upgrade flows; plan selection, interval toggle, per-seat pricing, portal/checkout redirects, and modal UX.
Settings Shell & Routing
editor/app/(site)/organizations/[organization_name]/settings/_shell.tsx, editor/app/(site)/organizations/[organization_name]/settings/layout.tsx, editor/app/(workspace)/universal/[[...path]]/page.tsx, editor/host/url.ts
Add SettingsShell UI, SettingsLayout (auth + org resolution + plan), organization-scoped universal routes, and update universal route builder/types to support organization scope.
Types & Client Changes
editor/types/types.ts, database/database-generated.types.ts, editor/k/labels.ts, editor/scaffolds/workspace/*.tsx, editor/scaffolds/workspace/workspace.tsx
Introduce PlanTier (`free
E2E Tests & Fixtures
editor/lib/billing/__tests__/e2e/fixtures/*, editor/lib/billing/__tests__/e2e/scenarios/*, editor/lib/billing/__tests__/e2e/README.md
Add E2E fixtures (deliverEvent, ephemeral orgs, DB readers, safety checks) and scenarios (lifecycle, idempotency, tampered-signature) with README and environment gating.
SQL Tests & Seeds
supabase/tests/test_grida_billing_test.sql, supabase/seed.sql
Add pgTAP test suite for grida_billing exercising provisioning, projector events, disputes, idempotency, RLS; update seed to remove display_plan and document trigger-based provisioning.
Pricing & Marketing
editor/www/data/pricing.ts, editor/www/pricing/pricing-comparison-table.tsx, editor/www/pricing/pricing.tsx, editor/app/(www)/(pricing)/pricing/_sections/faq.tsx, editor/app/(www)/(pricing)/pricing/page.tsx
Restructure pricing data (commerce/channels/support), rename features, remove Ticketing, add Channels/Support rows in UI, add Pricing FAQ, change default billing period to monthly.
Tooling & Config
editor/.env.test, .gitignore, editor/.oxlintrc.jsonc, editor/tsconfig.json, editor/vitest.config.ts, lefthook.yml
Add committed editor/.env.test, adjust .gitignore to keep .env.test and ignore /TODO.md, restrict Stripe imports to billing seam with an override, exclude scripts in tsconfig, add Vitest gating for billing E2E, and include MDX in lefthook glob.
Docs & Test Specs
docs/contributing/billing.md, docs/platform/billing.mdx, test/billing-*.md
Add contributor billing setup guide, platform billing MDX, and extensive test-spec docs for payments, quotas/AI, subscriptions/Stripe, and UX/edge cases.
Minor UI/UX tweaks
editor/app/(site)/organizations/[organization_name]/settings/profile/page.tsx, editor/app/(insiders)/insiders/auth/basic/sign-in/route.ts
Remove breadcrumb/nav from profile settings; use absolute-origin URL for redirect when redirect_uri is provided.

Possibly related PRs

  • gridaco/grida#490: Modifies generated DB typing surface; may intersect with database/database-generated.types.ts.
  • gridaco/grida#554: Pricing UI/data updates that overlap editor/www pricing changes.

Estimated code review effort

🎯 5 (Critical) | ⏱️ ~120 minutes

Suggested labels

migration, breaking

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch canary

Bring the billing guide and pricing FAQ in line with the live plans
data: four plans (Free / Pro / Team / Enterprise), Pro and Team both
per-seat with their own per-seat AI credit allotment ($10 and $35),
20% annual discount available on both. Removes the stale "no separate
Team plan" claim and updates examples (Priya's team upgrades to Team,
Sam's Pro renewal shows $10 monthly credit).
Add four behavior-spec files under `test/` covering the full v1 billing
surface: subscription lifecycle and Stripe consistency, quota math and
the AI gate, payment failures and fraud vectors, and user-facing UX +
operational edge cases. Cases are written as natural-language flows
(symptoms + expected outcomes), implementation-agnostic so the spec
survives schema iteration.

Also correct the top-up fee math in the user-facing billing guide
($25.73 → $26.06, accounting for the fee-on-fee), and trim a stale
trailing FAQ entry.
@softmarshmallow softmarshmallow changed the title feat(pricing): add billing guide, FAQ, and clean up features table feat(billing): SaaS billing v1 (subs, seats, portal, invoices) + design + natural-language tests for AI credits expansion May 5, 2026
@softmarshmallow softmarshmallow changed the title feat(billing): SaaS billing v1 (subs, seats, portal, invoices) + design + natural-language tests for AI credits expansion feat(billing): launch Pro and Team plans with self-serve subscription, seats, and invoices May 5, 2026
…ing_tier

Introduces the Stripe-backed billing schema (grida_billing.*) with
account, customer, subscription, invoice, payment_method, stripe_event,
billing_audit, and product_catalogue tables, plus the
fn_billing_apply_stripe_event projector and supporting RPCs. Adds 38
pgTAP scenarios covering RLS, projector branches, idempotency, annual
catalogue resolution, and the new is_enterprise flag.

Drops the unused public.organization.display_plan column and
public.pricing_tier enum — plan is now derived from
v_billing_subscription. Adds organization.is_enterprise boolean as a
separate ops flag.

Includes the contributor setup guide at docs/contributing/billing.md.
…lint rule

- editor/lib/billing/index.ts: single Stripe seam exporting the typed
  client, BillingError, auth helpers, redirect-URL validation, data
  helpers, and the dispatchStripeEvent projector entry point.
- editor/.oxlintrc.jsonc: enforces the seam — no-restricted-imports
  blocks 'stripe' value imports outside lib/billing/index.ts.
- editor/lib/supabase/server.ts: documents the inline-callsite rule for
  service_role.<schema>.* (don't alias — preserves grep-ability).
- editor/scripts/billing/setup-stripe-test.ts: idempotent test-mode
  setup of products, monthly+annual prices, and Customer Portal config.
- tsconfig: exclude scripts/ from typecheck (script lives outside the
  Next.js compile root).
Verifies the Stripe signature and dispatches the event into the
PL/pgSQL projector via dispatchStripeEvent. Signature verification
runs before any DB write; tampered or missing signatures return 400
without touching grida_billing.stripe_event.
- /organizations/<org>/settings/billing — Subscription Plan,
  Past Invoices, Payment Methods. Adjust plan and Cancel buttons
  guarded by past_due/unpaid/paused/incomplete states.
- /settings/billing/upgrade — Monthly/Annual toggle, plan cards
  (Free → Paid via Stripe Checkout, paid → paid via Customer Portal
  flow_data subscription_update_confirm). Embedded as a modal via
  parallel-route @modal slot for in-place upgrade.
- Server actions: getBillingSummary, listInvoices, updateSeats,
  startSubscribeCheckout, startPlanChangeConfirm, startCancelSubscription,
  startPaymentMethodUpdate, listBillingAudit.
- Settings shell with sidebar nav (Profile / Billing / Members).
  Profile page drops its bespoke breadcrumb header.
- host/url.ts: adds 'organization' route scope so universal links
  can target /organizations/<org>/<path>.
- Universal route picker handles organization-scope: redirects when
  the user has exactly one org, otherwise renders an org selector.
- types: PlatformPricingTier (free|v0_pro|v0_team|v0_enterprise) →
  PlanTier (free|pro|team). Enterprise becomes a separate
  organization.is_enterprise flag.
- /api/private/workspace/[organization_id]: resolves each org's plan
  from v_billing_subscription and exposes it as Organization.plan.
  Falls back to "free" when there's no active subscription.
- Workspace sidebar PricingTierCard reads { plan, is_enterprise } and
  links the Upgrade button to /settings/billing/upgrade.
- Org switcher badge uses Labels.planTier(plan, is_enterprise).
- Workspace reducer's init/organizations action refreshes
  state.organization with the SWR-fetched value, so the server-render
  default of plan:"free" gets replaced once real data lands.
Annual is the upsell, not the headline price. New visitors should see
the real monthly default first, then opt into the annual view.
- editor/lib/billing/__tests__/e2e: lifecycle, idempotency, and
  tampered-signature scenarios. Hits real Stripe test mode and the
  local webhook receiver via the deliver-event fixture; assertSuiteSafety
  refuses to run unless STRIPE_SECRET_KEY is sk_test_ and BILLING_TEST_MODE
  is true.
- editor/.env.test: non-secret defaults (NODE_ENV=test, BILLING_E2E=1
  off-by-default in shell, local Supabase URL). Secrets go in
  .env.test.local (gitignored). Untracks .env.test from .gitignore.
- editor/vitest.config.ts: inline parser auto-loads .env.test +
  .env.test.local. E2E suite is excluded from default runs and runs
  sequentially when BILLING_E2E=1 (shared Stripe rate limits).
Adds SUB-052..058 covering the same-plan interval upgrade flow
(Pro monthly → Pro annual, Team monthly → Team annual) routed
through Customer Portal subscription_update_confirm.
@softmarshmallow

Copy link
Copy Markdown
Member Author

@coderabbitai review

@coderabbitai

coderabbitai Bot commented May 6, 2026

Copy link
Copy Markdown
✅ Actions performed

Review triggered.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

2 similar comments
@coderabbitai

coderabbitai Bot commented May 6, 2026

Copy link
Copy Markdown
✅ Actions performed

Review triggered.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

@coderabbitai

coderabbitai Bot commented May 6, 2026

Copy link
Copy Markdown
✅ Actions performed

Review triggered.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

Note

Due to the large number of review comments, Critical severity comments were prioritized as inline comments.

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (5)
editor/host/url.ts (1)

334-341: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Fail fast when required route context is missing.

The loose overload now allows proj/docId to be absent, but this implementation interpolates them directly, so callers can silently get /acme/undefined/... or /acme/proj/null. Throwing here is much safer than emitting broken links.

Suggested fix
   if (route.scope === "organization") {
     const base = `/organizations/${context.org}`;
     return suffix ? `${base}/${suffix}` : base;
   }
 
+  if (!context.proj) {
+    throw new Error(`buildUniversalDestination(${page}): missing project context`);
+  }
   const base = `/${context.org}/${context.proj}`;
 
   if (route.scope === "project") {
     return suffix ? `${base}/${suffix}` : base;
   }
 
-  const docId = "docId" in context ? context.docId : "";
+  if (!context.docId) {
+    throw new Error(`buildUniversalDestination(${page}): missing document context`);
+  }
+  const docId = context.docId;
   return suffix ? `${base}/${docId}/${suffix}` : `${base}/${docId}`;
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@editor/host/url.ts` around lines 334 - 341, The current URL builder allows
missing required context (proj/docId) and may produce invalid paths; update the
logic around context, route.scope, base, and suffix to validate required fields
and throw if absent: when route.scope === "project" assert that context.proj
exists (throw a descriptive Error if not), and for non-project routes assert
both context.proj and context.docId exist (throw if either is missing) before
composing base and returning `${base}/${suffix}` or
`${base}/${docId}/${suffix}`; use the existing symbols context, route.scope,
proj, docId, base, and suffix to locate and implement the checks and throws.
editor/www/data/pricing.ts (1)

68-77: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Don’t mark extra-credit purchases as available before the topup flow ships.

This table now advertises "Buy extra credits" as enabled for paid plans, but the rest of this billing work still treats topups as deferred/future behavior. Shipping this as true will send users to a capability they can’t actually use yet.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@editor/www/data/pricing.ts` around lines 68 - 77, The "Buy extra credits"
pricing entry currently advertises availability for paid plans but the topup
flow isn't implemented; locate the pricing object whose title is "Buy extra
credits" in editor/www/data/pricing.ts and set the plans flags (e.g., pro, team,
enterprise) to false (and/or flip usage_based as appropriate) so the UI no
longer shows this option as enabled until the topup feature ships.
editor/scaffolds/workspace/workspace.tsx (1)

147-155: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Defaulting every org to free creates a transient downgrade on first paint.

Paid organizations will briefly look free until the SWR refresh lands, so any plan-based label or gating read from state.organization can flicker or disable paid UX incorrectly. It’s safer to preserve the server value when present, or model this as unknown/loading instead of hard-coding free.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@editor/scaffolds/workspace/workspace.tsx` around lines 147 - 155, The current
workspace organization object hard-codes plan: "free" which causes a transient
downgrade; change the plan assignment in the organization spread inside
workspace.tsx to preserve an existing server value when present (e.g., use
organization.plan or state.organization.plan) or set it to an explicit
unknown/loading value instead of always "free", so UI reads the real plan until
SWR refresh replaces state.organization; update references around the
organization spread and the plan property to use the preserved value or a
loading sentinel rather than unconditionally "free".
test/billing-payment-and-money-safety.md (1)

1-264: 🛠️ Refactor suggestion | 🟠 Major | 🏗️ Heavy lift

This test/**/*.md file is acting like a broad billing spec, not a manual UX test case.

Most of these scenarios are webhook/state/money-safety invariants that belong in automated tests or product docs, and the file also bundles dozens of independent behaviors without a ## Steps section. Please split out any true human-verification UX cases and move the automatable/payment-policy coverage elsewhere.

As per coding guidelines, test/**/*.md: "Add manual test cases to the test directory only for UX bugs requiring human interaction verification ... not for pure logic, math, or data transformations", "Ensure each manual test case file covers only one independent behavior", and "Write the ## Steps section in manual test cases with sufficient detail for someone unfamiliar with the feature to verify the behavior".

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@test/billing-payment-and-money-safety.md` around lines 1 - 264, This file
mixes many automatable billing invariants and policy specs into a single manual
test file; split it into: (1) a concise manual UX test(s) that each cover only
one human-verification behavior with a clear "## Steps" section (e.g., create
separate files for any test that requires human UI interaction), and (2) move
all pure logic/webhook/money-safety scenarios (e.g., TC-BILLING-PAY-001 through
TC-BILLING-PAY-044) into automated test suites or product/policy docs; ensure
each new manual MD contains only one behavior and step-by-step verification
instructions, and name files using the existing identifiers (TC-BILLING-PAY-###
headings) so the specific cases are easy to locate.
editor/lib/billing/marketing-plans.ts (1)

74-87: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

The pricing copy still advertises multi-seat billing.

Both paid tiers say per seat/month and Seats: ♾️, but this PR ships single-seat subscriptions only. That will promise functionality the billing flow cannot actually sell.

Also applies to: 96-109

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@editor/lib/billing/marketing-plans.ts` around lines 74 - 87, Update the
pricing copy to reflect single-seat subscriptions: change the costUnit value
from "per seat/month" to "per month" for each plan and update the corresponding
feature entry in the features array that has name "Seats" (replace the trail
"♾️" with "1" or remove the Seats feature entirely) so the UI no longer
advertises multi-seat billing; apply the same edits for both plan objects
referenced around costUnit and features (the block containing priceMonthly,
costUnit, and the features array).
🟠 Major comments (20)
editor/app/(www)/(pricing)/pricing/_sections/faq.tsx-56-59 (1)

56-59: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

FAQ describes multi-seat billing mechanics that are explicitly deferred in this PR.

The PR description states: "Single-seat subscriptions only: every subscription is created and maintained with quantity = 1; multi-seat billing deferred (tracked: GRIDA-60)."

Yet this FAQ item tells users that adding a member "adds a seat (prorated for the rest of the period)", removing one "credits the next invoice", and gives a concrete 5-seat pricing example — all of which currently behave differently (quantity stays at 1). Users who rely on this as documentation will be confused and file support tickets when seat-syncing doesn't happen.

Consider either:

  • Replacing the forward-looking mechanics with the actual current behaviour ("seats are billed manually for now; multi-seat automation is coming"), or
  • Deferring this FAQ item until GRIDA-60 ships.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@editor/app/`(www)/(pricing)/pricing/_sections/faq.tsx around lines 56 - 59,
The FAQ entry under pricing/_sections/faq.tsx that has question "How does
pricing work for teams?" currently describes multi-seat billing mechanics;
update the answer to reflect the current single-seat behavior (every
subscription is created/maintained with quantity = 1 and multi-seat automation
is deferred: reference GRIDA-60) or remove/hold the entire FAQ object until
GRIDA-60 ships; modify the object whose question string equals "How does pricing
work for teams?" so the answer explicitly states seats are billed manually /
multi-seat support is not yet implemented and avoid concrete prorated/5-seat
examples.
editor/app/(site)/organizations/[organization_name]/settings/billing/layout.tsx-4-9 (1)

4-9: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

React is used as a type namespace but never imported — this will cause a TypeScript compilation error.

React.ReactNode references the React namespace at the type level, but unlike JSX syntax (handled transparently by the react-jsx transform), namespace type references still require an explicit import. The project's TypeScript config uses jsx: "react-jsx" which allows JSX without imports, but the compiler will fail on React.ReactNode without the namespace in scope. Compare with editor/app/(embed)/embed/layout.tsx which correctly imports type { ReactNode } from "react" and uses it directly.

🐛 Proposed fix
+import type { ReactNode } from "react";

 export default function BillingLayout({
   children,
   modal,
 }: {
-  children: React.ReactNode;
-  modal: React.ReactNode;
+  children: ReactNode;
+  modal: ReactNode;
 }) {
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@editor/app/`(site)/organizations/[organization_name]/settings/billing/layout.tsx
around lines 4 - 9, The file declares BillingLayout using React.ReactNode types
but never imports React, causing a TypeScript error; update the top of the file
to import the types (for example: import type { ReactNode } from "react") and
change the prop annotations to use ReactNode (or alternatively import React
itself) so that the BillingLayout props (children, modal) compile correctly;
refer to the BillingLayout function and the React.ReactNode type usage when
making the change.
editor/lib/billing/__tests__/e2e/fixtures/org.ts-80-93 (1)

80-93: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Don’t silently succeed after Stripe cleanup failures.

If subscription cancellation delivery or customer deletion fails here, teardown can still complete locally and leave orphaned Stripe state behind for later runs. For the only cleanup path in these E2E fixtures, that turns transient cleanup failures into hidden account pollution.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@editor/lib/billing/__tests__/e2e/fixtures/org.ts` around lines 80 - 93, The
current teardown swallows Stripe failures (in the subscription cancellation
catch and the stripe.customers.del catch), logging warnings and continuing which
leaves orphaned Stripe state; change both catch blocks to surface failures by
rethrowing the original error (or throw a new Error with the original message)
after logging so the E2E fixture teardown fails instead of silently
succeeding—look for the subscription cancellation try/catch and the
stripe.customers.del call in this file and replace their console.warn-only
catches with code that logs then throws the error.
editor/host/url.ts-52-58 (1)

52-58: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

settings now collides with the existing document-scoped universal route.

There is already a document-scoped settings entry below. Adding another settings path here means the organization route overwrites the document route in UNIVERSAL_ROUTE_CONFIGS, and even a later id-only fix would still leave /_/settings ambiguous. The organization pages need a distinct universal path/id namespace.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@editor/host/url.ts` around lines 52 - 58, ORGANIZATION_ROUTE_CONFIGS
introduces a duplicate "settings" key that collides with the document-scoped
"settings" entry in UNIVERSAL_ROUTE_CONFIGS; rename the org-scoped keys to a
distinct namespace (e.g., "organization/settings" or "org/settings" and
corresponding "organization/settings/billing",
"organization/settings/billing/upgrade") and update any places that merge or
reference ORGANIZATION_ROUTE_CONFIGS so the org routes no longer overwrite the
document-scoped "settings" entry in UNIVERSAL_ROUTE_CONFIGS.
editor/lib/billing/__tests__/e2e/fixtures/deliver-event.ts-75-80 (1)

75-80: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

The non-JSON fallback loses the original response body.

res.json() consumes the stream before throwing on invalid JSON, so the res.text() call in the catch will usually fail too. That makes the helper least useful when the webhook returns an HTML/plaintext error page.

Suggested fix
   let body: DeliverResult["body"];
-  try {
-    body = (await res.json()) as DeliverResult["body"];
-  } catch {
-    body = { error: `non-json: ${await res.text()}` };
-  }
+  const raw = await res.text();
+  try {
+    body = JSON.parse(raw) as DeliverResult["body"];
+  } catch {
+    body = { error: `non-json: ${raw}` };
+  }
   return { status: res.status, body };
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@editor/lib/billing/__tests__/e2e/fixtures/deliver-event.ts` around lines 75 -
80, The fallback loses the original response because res.json() consumes the
body and prevents res.text() from reading it; modify the handler in this fixture
so you first read the raw text (e.g., via await res.clone().text() or await
res.text()), store it, then attempt to parse JSON from that raw string and set
body as DeliverResult["body"] accordingly (use the stored raw for the non-JSON
error case). Update the logic around res.json()/res.text() to avoid consuming
the stream twice and ensure the original response body is preserved in the
fallback.
editor/app/(workspace)/universal/[[...path]]/page.tsx-59-62 (1)

59-62: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Handle the membership query failure instead of rendering the empty state.

If this select errors, memberships becomes null and the branch tells the user they have no organizations. That masks auth/RLS/backend failures as a normal empty result.

Suggested fix
-    const { data: memberships } = await client
+    const { data: memberships, error: membershipsError } = await client
       .from("organization_member")
       .select(`organization:organization(name)`)
       .eq("user_id", auth.user.id);
+    if (membershipsError) throw membershipsError;
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@editor/app/`(workspace)/universal/[[...path]]/page.tsx around lines 59 - 62,
The membership query currently destructures only data so query failures (error
!= null) produce memberships === null and are shown as an empty state; update
the call that uses client.from(...).select(...) to also destructure the error
(e.g., const { data: memberships, error } = await
client.from(...).select(...).eq(...)), then branch on error before rendering:
either throw or render an error UI/log the error via your logger instead of
treating null as "no organizations" — make this change where memberships is used
so the component/page (the [[...path]] page) distinguishes real empty results
from backend/auth/RLS failures.
supabase/tests/test_grida_billing_test.sql-282-307 (1)

282-307: 🛠️ Refactor suggestion | 🟠 Major | ⚡ Quick win

Missing write-isolation coverage on grida_billing tables (per RLS guideline).

Section 9 only proves read isolation on v_billing_subscription for insider/alice/random. The guideline requires both read and write isolation tests (insert/update/delete) for every table that alters RLS or tenant boundaries. Direct writes to grida_billing.subscription, grida_billing.account, and grida_billing.audit by authenticated users (and to v_billing_subscription if that view is updatable) should be asserted to fail — that's what locks the "service_role-only mutation" invariant from the PR description into a regression test.

Suggested additions (sketch):

SET LOCAL ROLE authenticated;
SELECT set_config('request.jwt.claim.sub', current_setting('test.alice_uid'), true);

SELECT throws_ok($$
  INSERT INTO grida_billing.subscription (organization_id, plan, status, is_free, quantity)
  VALUES (2, 'pro', 'active', false, 1)
$$, NULL, NULL, 'authenticated cannot INSERT into grida_billing.subscription');

SELECT throws_ok($$
  UPDATE grida_billing.subscription SET status='canceled' WHERE organization_id=2
$$, NULL, NULL, 'authenticated cannot UPDATE grida_billing.subscription');

SELECT throws_ok($$
  DELETE FROM grida_billing.subscription WHERE organization_id=2
$$, NULL, NULL, 'authenticated cannot DELETE from grida_billing.subscription');

RESET ROLE;

(Also bump plan(37) accordingly.)

As per coding guidelines: "Add pgTAP test coverage for every table that alters RLS, permissions, or tenant boundaries, including at minimum read isolation tests and write isolation tests (insert/update/delete)".

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@supabase/tests/test_grida_billing_test.sql` around lines 282 - 307, Add
write-isolation pgTAP assertions for grida_billing tables and the
v_billing_subscription view: while running as SET LOCAL ROLE authenticated and
after setting request.jwt.claim.sub (e.g., current_setting('test.alice_uid') and
a random user), assert that INSERT/UPDATE/DELETE on grida_billing.subscription,
grida_billing.account, grida_billing.audit (and v_billing_subscription if
updatable) all throw using throws_ok so authenticated cannot mutate those rows;
include separate throws_ok checks for INSERT, UPDATE, DELETE per table/view and
increment the pgTAP plan number accordingly.
docs/contributing/billing.md-93-93 (1)

93-93: 🛠️ Refactor suggestion | 🟠 Major | ⚡ Quick win

Link points outside /docs — violates docs guideline.

[test/billing-*.md](../../test/) resolves to /test/, which is outside the docs/ tree. Per the docs guideline, paths outside /docs must be referenced as inline code, not links.

♻️ Proposed fix
-User-facing billing docs: [`docs/platform/billing.mdx`](../platform/billing.mdx). Behaviour test cases: [`test/billing-*.md`](../../test/).
+User-facing billing docs: [`docs/platform/billing.mdx`](../platform/billing.mdx). Behaviour test cases: `test/billing-*.md`.

As per coding guidelines: "Never link outside /docs from docs markdown files; reference external paths as inline code instead".

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@docs/contributing/billing.md` at line 93, In docs/contributing/billing.md the
markdown link [`test/billing-*.md`](../../test/) points outside /docs and must
be converted to inline code; replace the link token
[`test/billing-*.md`](../../test/) with an inline code span such as
`../../test/` or ``test/billing-*.md`` so the external /test path is referenced
as code rather than a clickable link.
docs/platform/billing.mdx-18-29 (1)

18-29: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

This page documents multi-seat billing that v1 does not ship.

PR #705 is explicitly single-seat only, but this copy promises per-seat subscriptions, automatic seat scaling, invite/remove proration, and pooled credits that grow with headcount. That will set the wrong expectation for customers and support. Please rewrite these sections to describe the shipped quantity=1 behavior and call multi-seat/proration out as a future limitation instead.

Also applies to: 91-100, 150-169

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@docs/platform/billing.mdx` around lines 18 - 29, The billing doc currently
describes multi-seat features (per-seat pricing, pooled credits, automatic seat
scaling, proration) but PR `#705` ships single-seat only; update the copy to state
the shipped behavior is quantity=`1` (solo single-seat subscriptions), remove or
reword definitive language such as "Pro and Team are both seat-based." and "AI
credit pools at the **organization** level" to instead note per-seat/pooled
credits, proration, and auto-scaling are future features/limitations, and add
one clear sentence calling out that multi-seat billing, invite/remove proration,
and pooled credits are not available in v1 and will be addressed in a future
release; ensure references to annual discounts remain accurate but do not imply
multi-seat proration is supported.
editor/app/(site)/organizations/[organization_name]/settings/billing/upgrade/page.tsx-17-25 (1)

17-25: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Enforce org ownership before showing the billing upgrade flow.

This only checks that the user is signed in, then loads any organization by slug. If the organization row is readable to non-owners, another logged-in user can reach another org’s billing upgrade page and potentially trigger billing actions for it. Please scope this query by owner_id = auth.user.id or perform an explicit membership/role check before rendering UpgradeView.

Suggested guard
-  const { data: org } = await client
+  const { data: org } = await client
     .from("organization")
-    .select("id, name")
+    .select("id, name, owner_id")
     .eq("name", organization_name)
     .single();
 
   if (!org) return notFound();
+  if (org.owner_id !== auth.user.id) return notFound();
 
   return <UpgradeView orgId={org.id} orgName={org.name} />;
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@editor/app/`(site)/organizations/[organization_name]/settings/billing/upgrade/page.tsx
around lines 17 - 25, The org fetch currently only filters by slug
(organization_name) before rendering UpgradeView; tighten this by ensuring the
requesting user is the org owner or has appropriate billing role: modify the
query built from client.from("organization").select(...) to also filter by
owner_id = auth.user.id (or the equivalent current user id variable) or,
alternatively, perform an explicit membership/role check against your membership
table (e.g., organization_membership where user_id = auth.user.id and role IN
('owner','billing')) before returning <UpgradeView orgId={org.id}
orgName={org.name} />; ensure you reference the same symbols (organization_name,
client.from("organization").select, UpgradeView) so only authorized users can
reach the billing flow.
editor/types/types.ts-118-125 (1)

118-125: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

plan should not be required on a type you describe as conditional.

The docblock states this field is only present on responses that went through the workspace API path, but OrganizationWithAvatar makes it mandatory everywhere. This creates a type-soundness issue where organization.plan appears safe to access but may be undefined at runtime. Either split this into a billing-specific response type or make plan optional and narrow it where the API guarantees it.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@editor/types/types.ts` around lines 118 - 125, The OrganizationWithAvatar
type marks the conditional billing field plan (PlanTier) as required even though
the docblock says it only exists on certain API responses; change this by making
plan optional (plan?: PlanTier | undefined) or by creating a separate
billing-specific type (e.g., OrganizationWithAvatarAndPlan extends
OrganizationWithAvatar { plan: PlanTier }) and use that narrower type where the
workspace API guarantees the field; update any call sites that assume
organization.plan to either narrow the type (type guard or runtime check) or use
the billing-specific response type (refer to OrganizationWithAvatar and PlanTier
to locate the declaration and adjust consumers accordingly).
editor/app/(site)/organizations/[organization_name]/settings/billing/_view.tsx-505-509 (1)

505-509: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Gate the sandbox disclaimer behind an explicit test-mode signal.

This note renders for every organization, so live customers will be told their card is never billed. That is materially false outside billing test mode.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@editor/app/`(site)/organizations/[organization_name]/settings/billing/_view.tsx
around lines 505 - 509, The sandbox disclaimer paragraph (the <p> after
Separator in _view.tsx) must be rendered only when the organization is in
billing test mode; wrap that paragraph (or the whole block) in a conditional
that checks an explicit test-mode signal (e.g., organization.billingTestMode ||
organization.isBillingTestMode || a prop/hook like isBillingTestMode) so live
organizations don't see the message, and handle possible undefined
organization/billing fields safely (optional chaining or null checks); update
any callers/props or fetch logic to supply the test-mode flag if missing and
add/update a small test to assert the disclaimer only appears when that flag is
true.
editor/lib/billing/index.ts-274-308 (1)

274-308: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

resolveOrCreateStripeCustomer() can leak orphan Stripe customers.

The Stripe customer is created before the attach path is known to have succeeded. If the DB call fails, or two requests race past the initial cache miss, one of those external customers is dropped on the floor and every retry can mint another one.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@editor/lib/billing/index.ts` around lines 274 - 308,
resolveOrCreateStripeCustomer currently creates a Stripe customer before
ensuring the DB attach succeeded, which can leak orphan customers and race; fix
by re-checking getCustomerId(org_id) immediately after the initial cache miss
and before calling stripe.customers.create to avoid races, and if you still
create a Stripe customer then call
service_role.workspace.rpc("fn_billing_attach_stripe_customer", ...) but ensure
that on any attach failure you call stripe.customers.del(created.id) (or
schedule deletion) to clean up the newly created customer; update
resolveOrCreateStripeCustomer to perform the double-check-before-create and the
cleanup-on-attach-failure around stripe.customers.create and the
fn_billing_attach_stripe_customer RPC.
editor/app/(site)/organizations/[organization_name]/settings/billing/_view.tsx-232-257 (1)

232-257: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Show the error state before the skeleton gate.

When getBillingSummary() fails on first load, state stays null and err is set, but if (loading || !state) still returns the skeleton. That makes the retry UI unreachable and the page looks stuck loading forever.

💡 Minimal fix
-  if (loading || !state) {
+  if (loading) {
     return (
       <main className="container mx-auto py-10 max-w-4xl">
         <header className="mb-8">
           <h1 className="text-3xl font-bold">Billing</h1>
         </header>
@@
-  if (err) {
+  if (err && !state) {
     return (
       <main className="container mx-auto py-10 max-w-4xl">
         <h1 className="text-3xl font-bold mb-4">Billing</h1>
         <p className="text-destructive">Failed to load billing: {err}</p>
         <Button onClick={refresh} className="mt-4" variant="outline">
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@editor/app/`(site)/organizations/[organization_name]/settings/billing/_view.tsx
around lines 232 - 257, The error UI is currently unreachable because the
skeleton gate (if (loading || !state)) runs before checking err; change the
rendering order so the err check runs before the skeleton check (i.e., render
the error return when err is truthy) or modify the skeleton condition to exclude
error (e.g., if (loading || (!state && !err))). Update the component logic
around loading, state, and err (references: getBillingSummary(), refresh,
loading, state, err) so the Retry button and error message render when
getBillingSummary() fails on first load.
supabase/schemas/grida_billing.sql-612-643 (1)

612-643: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Don't collapse first-invoice failures into past_due.

This handler always writes past_due, but the rest of the PR distinguishes first-payment failures as incomplete / incomplete_expired. Stripe retains newly-created subscriptions in incomplete status initially (for ~23 hours) when the first invoice payment fails, transitioning to incomplete_expired if unpaid—it never uses past_due for first-payment failures. The current code unconditionally overwrites these states, causing the final status to become webhook-order dependent and the UI can fall back to the renewal-failure path for a brand-new subscription.

Mitigation
-      UPDATE grida_billing.subscription
-         SET status = 'past_due', updated_at = now()
-       WHERE stripe_subscription_id = v_sub_id;
+      UPDATE grida_billing.subscription
+         SET status = CASE
+               WHEN status IN ('incomplete', 'incomplete_expired') THEN status
+               ELSE 'past_due'
+             END,
+             updated_at = now()
+       WHERE stripe_subscription_id = v_sub_id;
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@supabase/schemas/grida_billing.sql` around lines 612 - 643, The handler
currently unconditionally sets subscription.status to 'past_due' for
invoice.payment_failed; instead detect first-invoice failures using
v_attempt_count (and/or the current subscription row) and preserve or set
'incomplete'/'incomplete_expired' as appropriate: SELECT status INTO
v_current_status FROM grida_billing.subscription WHERE stripe_subscription_id =
v_sub_id (reuse v_org_id lookup), then if v_current_status = 'incomplete' leave
it unchanged (or if v_attempt_count IS NULL or = 0 set status to 'incomplete'),
otherwise set status = 'past_due'; also write the same computed status into the
INSERT to grida_billing.audit and keep v_handler logic (v_handler :=
'invoice_payment_failed' / 'invoice_payment_failed_skipped') consistent. Ensure
you reference v_attempt_count, v_sub_id, v_org_id, v_current_status and the
grida_billing.subscription update/insert statements when making the change.
supabase/migrations/20260506132900_grida_billing.sql-794-805 (1)

794-805: 🛠️ Refactor suggestion | 🟠 Major | ⚡ Quick win

Use a safe search_path in the SECURITY DEFINER wrappers.

These public wrappers run as SECURITY DEFINER with search_path = grida_billing, public, pg_temp. That is the unsafe pattern the repo rules explicitly ban; pg_catalog should be first, and pg_temp should not be in the definer path. The same fix applies to the other public.fn_billing_* wrappers below.

Suggested fix
-SET search_path = grida_billing, public, pg_temp
+SET search_path = pg_catalog, public

As per coding guidelines, "For SECURITY DEFINER functions, set a safe search_path with SET search_path = pg_catalog, public".

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@supabase/migrations/20260506132900_grida_billing.sql` around lines 794 - 805,
The SECURITY DEFINER wrapper public.fn_billing_apply_stripe_event (and the other
public.fn_billing_* wrappers) uses an unsafe search_path; change the SET
search_path clause to a safe value by setting "SET search_path = pg_catalog,
public" (remove pg_temp and ensure pg_catalog is first) so the wrapper still
calls grida_billing.fn_apply_stripe_event but runs with a safe definer
search_path.
editor/app/(site)/organizations/[organization_name]/settings/billing/_actions.ts-318-326 (1)

318-326: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Block plan changes for non-healthy subscriptions.

getActivePaidSubscription() returns any non-canceled paid row, so this path still opens subscription_update_confirm for past_due, unpaid, incomplete, and similar states. That contradicts the PR requirement to disable plan changes until payment issues are resolved.

Suggested fix
   const sub = await getActivePaidSubscription(org_id);
   if (!sub) {
     throw new BillingError(
       "No active paid subscription to change. Upgrade first.",
       "not_subscribed",
       400,
       "billing/upgrade"
     );
   }
+  if (sub.status !== "active" && sub.status !== "trialing") {
+    throw new BillingError(
+      "Resolve the current billing issue before changing plans.",
+      "plan_change_blocked",
+      409,
+      "billing"
+    );
+  }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@editor/app/`(site)/organizations/[organization_name]/settings/billing/_actions.ts
around lines 318 - 326, getActivePaidSubscription currently returns any
non-canceled paid row, so before allowing plan-change flow (the branch that
would open subscription_update_confirm) validate the subscription health by
checking the returned sub.status (from getActivePaidSubscription) against
allowed healthy states (e.g., "active") or reject known bad states
("past_due","unpaid","incomplete", etc.); if the status is not healthy, throw
the existing BillingError (the same class used here) with a payment-issue
message and appropriate code so plan changes are blocked until payment is
resolved. Ensure you update the branch that currently proceeds after const sub =
await getActivePaidSubscription(org_id) to perform this status check and throw
early when unhealthy.
supabase/migrations/20260506132900_grida_billing.sql-502-513 (1)

502-513: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Fail closed when a Stripe price is unmapped.

Falling back to "pro" here will silently mis-project any unknown or newly added price as Pro. That writes the wrong plan into the local source of truth and can under/over-entitle the org until someone fixes it manually.

Suggested fix
-    v_plan := 'pro';
+    v_plan := NULL;
     IF v_price_id IS NOT NULL THEN
       SELECT CASE
                WHEN id IN ('plan.team', 'plan.team.annual') THEN 'team'
                WHEN id IN ('plan.pro',  'plan.pro.annual')  THEN 'pro'
-               ELSE 'pro'
+               ELSE NULL
              END
         INTO v_plan
         FROM grida_billing.product_catalogue
        WHERE stripe_price_id = v_price_id;
-      v_plan := coalesce(v_plan, 'pro');
     END IF;
+    IF v_plan IS NULL THEN
+      RAISE EXCEPTION 'subscription % has unknown stripe price %', v_sub_id, v_price_id;
+    END IF;
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@supabase/migrations/20260506132900_grida_billing.sql` around lines 502 - 513,
The current logic in the block around v_plan, v_price_id and the
grida_billing.product_catalogue lookup silently falls back to 'pro' for unmapped
stripe_price_id values; change it to fail-fast: after attempting the SELECT ...
INTO v_plan from product_catalogue (matching on stripe_price_id), check whether
a row was FOUND or whether v_plan is NULL and if so RAISE EXCEPTION (including
the v_price_id for context) instead of coalescing to 'pro'; this keeps the
incorrect default from being written and surfaces unmapped/new Stripe prices for
explicit handling.
supabase/migrations/20260506132900_grida_billing.sql-353-370 (1)

353-370: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Don’t return attached=true when no account row was updated.

If provisioning failed or p_org_id is invalid, SELECT ... FOR UPDATE finds no row, the UPDATE affects 0 rows, and this function still reports success and writes an audit entry. Callers will believe the customer is cached even though grida_billing.account is unchanged.

Suggested fix
   SELECT acc.stripe_customer_id INTO v_existing
     FROM grida_billing.account acc
    WHERE acc.organization_id = p_org_id
    FOR UPDATE;
+
+  IF NOT FOUND THEN
+    RAISE EXCEPTION 'billing account not provisioned for organization %', p_org_id;
+  END IF;

   IF v_existing IS NULL THEN
     UPDATE grida_billing.account
        SET stripe_customer_id = p_stripe_customer_id,
            updated_at         = now()
      WHERE organization_id = p_org_id;
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@supabase/migrations/20260506132900_grida_billing.sql` around lines 353 - 370,
The function currently sets v_attached = true and writes an audit even when no
account row was actually updated; after the UPDATE on grida_billing.account (the
statement that uses WHERE organization_id = p_org_id) capture whether the UPDATE
affected a row (use IF FOUND or GET DIAGNOSTICS ROW_COUNT into a local var) and
only set v_attached := true and INSERT into grida_billing.audit when the UPDATE
affected 1 row; if no row was updated, leave v_attached false (or return an
error) and do not write the audit. Ensure you reference and check v_existing,
p_org_id and p_stripe_customer_id around that UPDATE/INSERT block.
supabase/migrations/20260506132900_grida_billing.sql-442-446 (1)

442-446: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Lock the stripe_event row before the replay check.

Two concurrent deliveries of the same event can both read processed_at IS NULL here and execute the projector body, so idempotency only holds after the first commit. That leaves duplicate audit rows and makes the webhook ingest contract racey.

Suggested fix
-  SELECT * INTO v_existing FROM grida_billing.stripe_event WHERE id = p_event_id;
+  SELECT *
+    INTO v_existing
+    FROM grida_billing.stripe_event
+   WHERE id = p_event_id
+   FOR UPDATE;
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@supabase/migrations/20260506132900_grida_billing.sql` around lines 442 - 446,
The race comes from reading grida_billing.stripe_event into v_existing without
locking, so two concurrent deliveries can see processed_at IS NULL and both
proceed; change the fetch to acquire a row lock (SELECT ... FOR UPDATE) on
grida_billing.stripe_event for p_event_id before checking
v_existing.processed_at so the second transaction will wait and observe the
committed processed_at; update the SELECT INTO that populates v_existing (and
any related reads) to use FOR UPDATE within the same function/transaction that
handles the projector.
🟡 Minor comments (6)
editor/.env.test-11-15 (1)

11-15: ⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Precedence comment is incorrect

The comment in .env.test states that process.env (from your shell) has the lowest priority (#1), overridden by .env.test.local (#3). However, vitest.config.ts explicitly states: "Existing process.env always wins."

The loadEnvFile() function only sets variables that don't already exist in process.env (if (!(key in process.env))), making shell-injected env vars have the highest priority, not the lowest.

Update the comment to reflect actual behavior: existing process.env values are never overridden by either .env.test or .env.test.local.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@editor/.env.test` around lines 11 - 15, Update the inaccurate precedence
comment in .env.test to reflect actual behavior: existing process.env values are
never overridden by env files. Mention that vitest.config.ts's loadEnvFile()
(which checks if (!(key in process.env))) gives shell-injected variables highest
priority, and that .env.test and .env.test.local only set variables when not
already present (with .env.test.local remaining for per-developer secrets but
still not overriding process.env).
editor/app/(site)/organizations/[organization_name]/settings/_shell.tsx-37-43 (1)

37-43: ⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

"Billing" and "Upgrade plan" are both highlighted simultaneously on the upgrade page.

When pathname is /organizations/{org}/settings/billing/upgrade, the startsWith check at line 71 marks "Billing" as active (because /billing/upgrade starts with /billing/), and "Upgrade plan" is also active via the exact-match. Two items light up at once.

Fix by using exact matching for "Billing" only when sub-items are visible, or by excluding child paths explicitly:

🐛 Proposed fix
  const isActive =
-   pathname === c.href || pathname?.startsWith(`${c.href}/`);
+   pathname === c.href ||
+   (pathname?.startsWith(`${c.href}/`) &&
+     !categories.some((other) => other !== c && other.href.startsWith(`${c.href}/`) && pathname?.startsWith(other.href)));

Or the simpler, explicit approach — change the "Billing" entry to use exact match only since "Upgrade plan" already covers the sub-path:

  {categories.map((c) => {
    const isActive =
-     pathname === c.href || pathname?.startsWith(`${c.href}/`);
+     c.href === `${settingsBase}/billing`
+       ? pathname === c.href
+       : pathname === c.href || pathname?.startsWith(`${c.href}/`);

Also applies to: 69-79

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@editor/app/`(site)/organizations/[organization_name]/settings/_shell.tsx
around lines 37 - 43, The "Billing" menu item in the categories array is being
marked active alongside its child "Upgrade plan" because the active check uses
startsWith on pathname; update the active resolution so parent items like the
Billing entry (in the categories const in _shell.tsx) use exact matching when a
child route exists: either change the Billing entry to be matched exactly
instead of via startsWith, or modify the active detection logic to prefer exact
path equality for parent items when any explicit child path (e.g.,
`${settingsBase}/billing/upgrade`) is present so only the intended item is
highlighted.
editor/vitest.config.ts-7-8 (1)

7-8: ⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Precedence comment has the order backwards.

The code gives process.env (shell) the highest priority (if (!(key in process.env))), so the true order is shell > .env.test.local > .env.test. The comment currently lists shell as the lowest priority, which will mislead developers who expect their .env.test.local values to override a shell-exported variable.

📝 Suggested correction
-// Precedence (later loses):
-// .env.test.local > .env.test > shell. Existing process.env always wins.
+// Precedence (highest wins):
+// shell > .env.test.local > .env.test. Existing process.env always wins.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@editor/vitest.config.ts` around lines 7 - 8, The precedence comment is
backwards: update the explanatory comment that starts with "without `set -a; .
./.env.test.local` ceremony. Precedence (later loses):" so it correctly
describes that existing process.env (shell) currently wins due to the `if (!(key
in process.env))` check; change the order to "shell > .env.test.local >
.env.test" (or reword to say "existing process.env takes precedence over
.env.test.local which takes precedence over .env.test") so the comment matches
the actual behavior implemented by that conditional.
docs/platform/billing.mdx-1-6 (1)

1-6: ⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Add keywords to the frontmatter.

This page is under docs/platform/** and already has title/description, so it is missing the remaining required SEO field. As per coding guidelines, docs/{wg,reference,editor,forms,platform,with-figma,design,math}/**/*.{md,mdx}: Add SEO frontmatter with title, description, and keywords when adding or meaningfully editing actively maintained doc pages.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@docs/platform/billing.mdx` around lines 1 - 6, The frontmatter in the Billing
doc is missing the required SEO `keywords` field; update the YAML frontmatter
that currently contains `title:`, `description:` (as shown in the file header)
to include a `keywords:` entry with a comma-separated list of relevant SEO terms
(e.g., "billing, pricing, AI credits, plans") so the page complies with the docs
frontmatter rule for docs/platform pages.
docs/platform/billing.mdx-193-194 (1)

193-194: ⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Keep docs links inside /docs.

/support/refund-policy is an internal route outside the docs tree, so this link breaks the repository rule for markdown docs. Point to the docs version of that policy instead, or reference the non-doc path as inline code. As per coding guidelines, docs/**/*.{md,mdx}: Never link outside /docs from docs markdown files; reference external paths as inline code instead.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@docs/platform/billing.mdx` around lines 193 - 194, The link in the "Can I get
a refund?" section points to an internal non-doc route (/support/refund-policy)
which violates the docs rule; update the link to point to the docs-hosted policy
path (e.g., the docs equivalent under /docs/... or the canonical docs route for
the refund policy) or, if you must reference the non-doc path, replace the
markdown link with inline code notation for /support/refund-policy so it no
longer creates an external docs link.
editor/app/(site)/organizations/[organization_name]/settings/billing/_actions.ts-596-603 (1)

596-603: ⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

The audit cursor can skip rows at page boundaries.

The result set is ordered by (created_at DESC, id DESC), but the next page only filters on created_at < cursor. Any rows that share the boundary timestamp become unreachable. Use a composite cursor (created_at, id) or switch the cursor to the monotonic id.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@editor/app/`(site)/organizations/[organization_name]/settings/billing/_actions.ts
around lines 596 - 603, The current pagination uses only created_at as the
cursor in the query built in the block that sets `if (cursor) q =
q.lt("created_at", cursor);` and computes `next_cursor` from `rows[rows.length -
1].created_at`, which can skip rows that share the same timestamp; change the
cursor to a composite cursor of (created_at, id) or use the monotonic `id` only:
update the query building (the `q` construction) to accept a composite cursor
and apply a predicate equivalent to (created_at < cursor.created_at OR
(created_at = cursor.created_at AND id < cursor.id)), update how `cursor` is
passed/parsed to include both `created_at` and `id`, and set `next_cursor` to
include both values (e.g., the last row's created_at and id) so page boundaries
are deterministic and no rows are skipped; alternatively, replace the created_at
filter entirely with `id < cursorId` and compute `next_cursor` from the last
row's id if you prefer a monotonic id cursor.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: fc2c749e-3480-4c21-9369-8425196b12e1

📥 Commits

Reviewing files that changed from the base of the PR and between 3b3b397 and e4e716a.

⛔ Files ignored due to path filters (1)
  • pnpm-lock.yaml is excluded by !**/pnpm-lock.yaml
📒 Files selected for processing (58)
  • .gitignore
  • database/database-generated.types.ts
  • docs/contributing/billing.md
  • docs/platform/billing.mdx
  • editor/.env.test
  • editor/.oxlintrc.jsonc
  • editor/app/(api)/private/webhooks/stripe/route.ts
  • editor/app/(api)/private/workspace/[organization_id]/route.ts
  • editor/app/(insiders)/insiders/auth/basic/sign-in/route.ts
  • editor/app/(site)/organizations/[organization_name]/settings/_shell.tsx
  • editor/app/(site)/organizations/[organization_name]/settings/billing/@modal/(.)upgrade/page.tsx
  • editor/app/(site)/organizations/[organization_name]/settings/billing/@modal/default.tsx
  • editor/app/(site)/organizations/[organization_name]/settings/billing/_actions.ts
  • editor/app/(site)/organizations/[organization_name]/settings/billing/_modal-shell.tsx
  • editor/app/(site)/organizations/[organization_name]/settings/billing/_view.tsx
  • editor/app/(site)/organizations/[organization_name]/settings/billing/layout.tsx
  • editor/app/(site)/organizations/[organization_name]/settings/billing/page.tsx
  • editor/app/(site)/organizations/[organization_name]/settings/billing/upgrade/_view.tsx
  • editor/app/(site)/organizations/[organization_name]/settings/billing/upgrade/page.tsx
  • editor/app/(site)/organizations/[organization_name]/settings/layout.tsx
  • editor/app/(site)/organizations/[organization_name]/settings/profile/page.tsx
  • editor/app/(workspace)/universal/[[...path]]/page.tsx
  • editor/app/(www)/(pricing)/pricing/_sections/faq.tsx
  • editor/app/(www)/(pricing)/pricing/page.tsx
  • editor/host/url.ts
  • editor/k/labels.ts
  • editor/lib/billing/__tests__/e2e/README.md
  • editor/lib/billing/__tests__/e2e/fixtures/db.ts
  • editor/lib/billing/__tests__/e2e/fixtures/deliver-event.ts
  • editor/lib/billing/__tests__/e2e/fixtures/org.ts
  • editor/lib/billing/__tests__/e2e/fixtures/safety.ts
  • editor/lib/billing/__tests__/e2e/scenarios/idempotency.test.ts
  • editor/lib/billing/__tests__/e2e/scenarios/lifecycle.test.ts
  • editor/lib/billing/__tests__/e2e/scenarios/tampered-signature.test.ts
  • editor/lib/billing/index.ts
  • editor/lib/billing/marketing-plans.ts
  • editor/lib/billing/plans.ts
  • editor/lib/supabase/server.ts
  • editor/package.json
  • editor/scaffolds/workspace/sidebar.tsx
  • editor/scaffolds/workspace/workspace.tsx
  • editor/scripts/billing/setup-stripe-test.ts
  • editor/tsconfig.json
  • editor/types/types.ts
  • editor/vitest.config.ts
  • editor/www/data/pricing.ts
  • editor/www/pricing/pricing-comparison-table.tsx
  • editor/www/pricing/pricing.tsx
  • lefthook.yml
  • supabase/migrations/20260506132900_grida_billing.sql
  • supabase/migrations/20260506132901_drop_legacy_pricing_tier.sql
  • supabase/schemas/grida_billing.sql
  • supabase/seed.sql
  • supabase/tests/test_grida_billing_test.sql
  • test/billing-payment-and-money-safety.md
  • test/billing-quota-and-ai.md
  • test/billing-subscription-and-stripe.md
  • test/billing-ux-and-edge-cases.md

Comment on lines +425 to +478
if (await getActivePaidSubscription(org_id)) {
throw new BillingError(
"Organization already has an active paid subscription.",
"already_subscribed",
409
);
}

const origin = await getOrigin();
const success_url = assertAllowedRedirect(params.success_url, origin);
const cancel_url = assertAllowedRedirect(params.cancel_url, origin);

const id = price_catalogue_id(params.plan, interval);
const cat = await getCatalogueStripeIds(id);
if (!cat) {
throw new BillingError(
`Stripe price for ${id} is not yet wired.`,
"billing_not_provisioned",
500
);
}

// v1 ships single-seat only. Multi-seat billing is deferred — when it
// lands, the quantity will come from a "Manage seats" UI that pushes
// through Stripe, never inferred from member count here.
const quantity = 1;

const customer = await resolveOrCreateStripeCustomer(org_id);
const idempotencyKey = `subscribe:${org_id}:${params.plan}:${interval}:${Math.floor(Date.now() / 60000)}`;

const session = await stripe.checkout.sessions.create(
{
mode: "subscription",
customer,
line_items: [{ price: cat.stripe_price_id, quantity }],
subscription_data: {
metadata: {
grida_organization_id: String(org_id),
grida_plan: params.plan,
grida_interval: interval,
},
},
success_url,
cancel_url,
metadata: {
grida_organization_id: String(org_id),
kind: "subscribe",
plan: params.plan,
interval,
},
allow_promotion_codes: true,
},
{ idempotencyKey }
);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical | 🏗️ Heavy lift

Checkout creation still allows duplicate live subscriptions.

This only checks local state before creating the Checkout Session. If two startSubscribeCheckout() calls happen before the first completion is projected, both sessions can complete in Stripe. The second webhook will then hit the single-active-subscription constraint added in supabase/migrations/20260506132900_grida_billing.sql Lines 88-90, leaving an extra live Stripe subscription billing the customer while the projector rejects it.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@editor/app/`(site)/organizations/[organization_name]/settings/billing/_actions.ts
around lines 425 - 478, The checkout creation can race because it only checks
local DB via getActivePaidSubscription; before calling
stripe.checkout.sessions.create you must also check the live Stripe customer to
prevent duplicate active subscriptions: after
resolveOrCreateStripeCustomer(org_id) call stripe.subscriptions.list({ customer,
status: "active" }) (and include "trialing" if you treat trials as active) and
throw the same BillingError if any live subscriptions exist; alternatively
create a short-lived reservation row or DB lock keyed by org_id inside
startSubscribeCheckout to prevent concurrent session creation—apply the Stripe
check (or reservation/lock) before calling stripe.checkout.sessions.create and
keep the existing idempotencyKey logic.

Projector hardening (SQL):
- Fail-closed mapping for unknown Stripe price ids — refuse to default to 'pro'
- SELECT FOR UPDATE on stripe_event so concurrent deliveries serialise
- IF NOT FOUND in fn_attach_stripe_customer (refuse silent "attached")
- Preserve incomplete*/incomplete_expired in invoice.payment_failed handler
- Safe SECURITY DEFINER search_path = pg_catalog, public

Server actions:
- Server-side guard rejects plan-change while past_due/incomplete/paused
- Stripe customer create now uses idempotencyKey + post-create recheck
- Composite (created_at, id) cursor on listBillingAudit
- New resumeSubscription server action: undoes cancel_at_period_end without
  charge, projects via webhook (no Portal flow_data exists for this)

UI:
- Dedicated /billing/return callback page owns post-Stripe-flow polling +
  redirect. Fullscreen loading spinner only; main billing page no longer
  watches ?subscribe=success or runs the polling loop
- Cancel moved to bottom-of-page Danger zone; primary CTAs stay clean
- Resume subscription button surfaces inline when cancel_at_period_end=true.
  Optimistic UI flip; webhook reconciles
- Annual price label includes monthly equivalent ($192/yr (~$16/mo))
- First-load error renders before skeleton gate so Retry is reachable
- Sandbox disclaimer gated on BILLING_TEST_MODE
- Settings sidebar fixes double-active when on /upgrade

Marketing/docs (single-seat v1 reality):
- Strip per-seat language from pricing tiles, FAQ, billing.mdx examples
- Disable "Buy extra credits" advert until top-up flow ships
- SEO frontmatter keywords on billing.mdx; fix dead refund-policy link

Tooling/tests:
- Fix catalogueId import in setup-stripe-test.ts (renamed price_catalogue_id)
- Org-scoped /_/settings collision: drop ambiguous org settings shorthand
- buildUniversalDestination throws on missing project/document context
- E2E fixtures: rethrow Stripe cleanup failures, read body as text first
- pgTAP wires fn_billing_setup_product before subscription.created tests
- Env precedence comments fixed (highest priority wins)

Known issue documented:
- TC-BILLING-SUB-059: concurrent checkout sessions can produce duplicate
  live Stripe subs. Risk is to Grida (manual refund), not customer.
  Closure tracked in GRIDA-60.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 9

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
test/billing-subscription-and-stripe.md (1)

1-355: 🛠️ Refactor suggestion | 🟠 Major | 🏗️ Heavy lift

This is too broad for a single manual test case in test/.

Most of this file is billing/spec logic rather than UX behavior that needs human interaction, and it bundles dozens of independent behaviors into one case. Please move the non-UX material to docs/specs and keep test/*.md for discrete interaction-heavy manual cases only.

As per coding guidelines, "test/**/*.md: Add manual test cases to the test directory only for UX bugs requiring human interaction verification ... not for pure logic, math, or data transformations" and "Ensure each manual test case file covers only one independent behavior".

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@test/billing-subscription-and-stripe.md` around lines 1 - 355, The test file
bundles dozens of independent billing specs (e.g. TC-BILLING-SUB-001 ...
TC-BILLING-SUB-059) and is too broad for a single manual test; split it by
responsibility: move pure-spec/math/data sections (all non-UI invariants and
lifecycle rules, e.g. TC-BILLING-SUB-002..TC-BILLING-SUB-056, the "Seats" and
"Stripe consistency" sections) into a docs/specs billing doc, and keep only
discrete, UX/manual cases (those requiring human interaction like Customer
Portal flows: TC-BILLING-SUB-001, TC-BILLING-SUB-004, TC-BILLING-SUB-007,
TC-BILLING-SUB-008, TC-BILLING-SUB-010, TC-BILLING-SUB-011, etc.) as separate
small files under test/, one behavior per file named by the TC ID; ensure each
test/*.md contains a single Expected/Steps block and update tags/status
accordingly.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@docs/contributing/billing.md`:
- Line 1: The Markdown file with the heading "Contributing to Grida | Billing"
is missing YAML frontmatter; add a frontmatter block at the top containing at
minimum "format: md" (e.g., ---\nformat: md\n---) so the document opts out of
MDX parsing; ensure the frontmatter appears before the existing "# Contributing
to Grida | Billing" heading and contains no JSX/MDX content.
- Line 1: The "Contributing to Grida | Billing" page lives in an unmaintained
docs location; move the page titled "Contributing to Grida | Billing" into an
actively maintained docs subdirectory (such as the project’s wg or reference
docs area), update any sidebars/navigation or cross-links that referenced the
old location, and ensure frontmatter/metadata (title, permalink) if present
reflects the new location so build/navigation continue to work.

In `@docs/platform/billing.mdx`:
- Around line 90-93: Update the overstated "the same minute the charge fails"
claim in the billing doc: locate the bullet that starts "Your subscription
pauses immediately. Not in 24 hours, not after a 7-day grace period — the same
minute the charge fails." and replace it with softened wording such as "Your
subscription is paused shortly after the charge fails (typically within a few
minutes)," and add a brief note that the system is webhook-driven (Stripe →
/api/billing/webhook → projector) so delivery can take seconds to a few minutes;
ensure the adjacent bullets (monthly AI credit suspended, top-up credit,
retries) remain consistent with this timing change.

In
`@editor/app/`(site)/organizations/[organization_name]/settings/billing/_view.tsx:
- Around line 375-380: The current code nests <Button> inside <Link>, creating
invalid interactive nesting; instead render the link as the button's child by
using the Button's "asChild" pattern: replace the <Link
href={`${baseUrl}/upgrade`}><Button ...>...</Button></Link> wrapper with <Button
asChild variant={isPaid ? "outline" : "default"}><Link
href={`${baseUrl}/upgrade`}>{isPaid ? "Adjust plan" :
"Upgrade"}</Link></Button>, keeping the same baseUrl and isPaid logic so the
visual button remains but the actual interactive element is the anchor.

In
`@editor/app/`(site)/organizations/[organization_name]/settings/billing/upgrade/_view.tsx:
- Around line 265-279: The card currently marks a plan as "Current" based only
on plan id (isCurrent = current?.plan === plan.id) which ignores the selected
billing interval; change the logic to consider the interval or adjust the badge
text: either compute a stricter matcher (e.g., isExactCurrent = current?.plan
=== plan.id && current?.interval === interval) and use that to render the Badge,
or keep the plan match but render the Badge as "Current — Monthly" / "Current —
Annual" using the existing interval variable so the badge reflects the selected
interval; update the JSX around isCurrent / Badge in the component to use these
new checks/text (refer to isCurrent, current?.plan, interval, and the Badge/
Card JSX).

In `@editor/lib/billing/__tests__/e2e/fixtures/org.ts`:
- Around line 88-96: The catch block in teardownOrg currently does "if (/No such
customer|resource_missing/i.test(msg)) return;" which aborts teardown early and
skips the local organization deletion; change this so missing-customer errors
are ignored but do not return from teardownOrg (e.g., remove the return and
optionally log/continue), ensuring the subsequent local organization delete
logic still runs after stripe.customers.del fails with resource_missing; update
the error handling around stripe.customers.del to only throw for unexpected
errors and allow execution to proceed to the organization deletion code path.

In `@supabase/schemas/grida_billing.sql`:
- Around line 733-745: fn_stamp_failure currently only updates an existing
grida_billing.stripe_event row, so when fn_apply_stripe_event inserts the row
and that transaction rolls back the UPDATE matches nothing; change
fn_stamp_failure to perform an UPSERT (insert ... on conflict do update) in its
own transaction so it can record a forensic failure regardless of caller
rollback, and accept/require the event type (add parameter p_event_type) so the
inserted row has necessary type metadata; update the caller
fn_apply_stripe_event to pass the event type when invoking fn_stamp_failure and
ensure fn_stamp_failure uses a transaction-independent context (e.g., LANGUAGE
plpgsql SECURITY DEFINER) to run the upsert.
- Around line 366-378: The code currently silently ignores attempts to bind a
different stripe_customer_id by updating grida_billing.account only when
v_existing IS NULL and otherwise proceeding; change this to fail closed by
detecting mismatches between v_existing and p_stripe_customer_id (the existing
stripe_customer_id vs the incoming p_stripe_customer_id) and return an
error/raise exception instead of no-oping; apply the same guard in the
customer.created/customer.updated projector branch where you currently mark the
event handled—if v_existing IS NOT NULL and v_existing <> p_stripe_customer_id
then RAISE/RETURN an error (or set an error state) rather than continuing, and
include this check around the same variables (v_existing, p_stripe_customer_id)
before inserting into grida_billing.audit.

In `@supabase/tests/test_grida_billing_test.sql`:
- Around line 26-31: Add pgTAP assertions that explicitly verify RLS/permission
denials for the internal tables grida_billing.account,
grida_billing.subscription, grida_billing.product_catalogue,
grida_billing.stripe_event, and grida_billing.audit: for each table add tests
that impersonate an anonymous role and an authenticated non-privileged role and
assert that SELECT is denied, and similarly assert INSERT, UPDATE, and DELETE
are denied (or allowed only where intended). Use the same test harness style as
the existing file (e.g., role-switching helper or SET ROLE/SET LOCAL and pgTAP
ok/fails assertions) to cover read isolation and write isolation for each table
so privilege drift on the internal surface is caught.

---

Outside diff comments:
In `@test/billing-subscription-and-stripe.md`:
- Around line 1-355: The test file bundles dozens of independent billing specs
(e.g. TC-BILLING-SUB-001 ... TC-BILLING-SUB-059) and is too broad for a single
manual test; split it by responsibility: move pure-spec/math/data sections (all
non-UI invariants and lifecycle rules, e.g.
TC-BILLING-SUB-002..TC-BILLING-SUB-056, the "Seats" and "Stripe consistency"
sections) into a docs/specs billing doc, and keep only discrete, UX/manual cases
(those requiring human interaction like Customer Portal flows:
TC-BILLING-SUB-001, TC-BILLING-SUB-004, TC-BILLING-SUB-007, TC-BILLING-SUB-008,
TC-BILLING-SUB-010, TC-BILLING-SUB-011, etc.) as separate small files under
test/, one behavior per file named by the TC ID; ensure each test/*.md contains
a single Expected/Steps block and update tags/status accordingly.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 3eefb340-afea-42e7-b0a0-3472b03f6357

📥 Commits

Reviewing files that changed from the base of the PR and between e4e716a and 0e13140.

📒 Files selected for processing (22)
  • docs/contributing/billing.md
  • docs/platform/billing.mdx
  • editor/.env.test
  • editor/app/(site)/organizations/[organization_name]/settings/_shell.tsx
  • editor/app/(site)/organizations/[organization_name]/settings/billing/_actions.ts
  • editor/app/(site)/organizations/[organization_name]/settings/billing/_view.tsx
  • editor/app/(site)/organizations/[organization_name]/settings/billing/return/_view.tsx
  • editor/app/(site)/organizations/[organization_name]/settings/billing/return/page.tsx
  • editor/app/(site)/organizations/[organization_name]/settings/billing/upgrade/_view.tsx
  • editor/app/(www)/(pricing)/pricing/_sections/faq.tsx
  • editor/host/url.ts
  • editor/lib/billing/__tests__/e2e/fixtures/deliver-event.ts
  • editor/lib/billing/__tests__/e2e/fixtures/org.ts
  • editor/lib/billing/index.ts
  • editor/lib/billing/marketing-plans.ts
  • editor/scripts/billing/setup-stripe-test.ts
  • editor/vitest.config.ts
  • editor/www/data/pricing.ts
  • supabase/migrations/20260506132900_grida_billing.sql
  • supabase/schemas/grida_billing.sql
  • supabase/tests/test_grida_billing_test.sql
  • test/billing-subscription-and-stripe.md
✅ Files skipped from review due to trivial changes (4)
  • editor/.env.test
  • editor/app/(site)/organizations/[organization_name]/settings/_shell.tsx
  • editor/scripts/billing/setup-stripe-test.ts
  • supabase/migrations/20260506132900_grida_billing.sql
🚧 Files skipped from review as they are similar to previous changes (7)
  • editor/vitest.config.ts
  • editor/lib/billing/tests/e2e/fixtures/deliver-event.ts
  • editor/lib/billing/marketing-plans.ts
  • editor/www/data/pricing.ts
  • editor/host/url.ts
  • editor/lib/billing/index.ts
  • editor/app/(site)/organizations/[organization_name]/settings/billing/_actions.ts

@@ -0,0 +1,115 @@
# Contributing to Grida | Billing

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Add required Markdown frontmatter with format: md.

For .md docs files, include frontmatter and set format: md to opt out of MDX parsing.

As per coding guidelines, "For files that don't use JSX/MDX features, add format: md to frontmatter to opt out of MDX parsing entirely and prevent angle-bracket issues."

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@docs/contributing/billing.md` at line 1, The Markdown file with the heading
"Contributing to Grida | Billing" is missing YAML frontmatter; add a frontmatter
block at the top containing at minimum "format: md" (e.g., ---\nformat: md\n---)
so the document opts out of MDX parsing; ensure the frontmatter appears before
the existing "# Contributing to Grida | Billing" heading and contains no JSX/MDX
content.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Move this page under an actively maintained docs directory.

This file is introduced under docs/contributing/, but this repository’s docs maintenance scope is limited to docs/wg/** and docs/reference/**.

As per coding guidelines, "When writing documentation, the root ./docs directory is the source of truth, and only actively maintain docs/wg/** and docs/reference/** directories."

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@docs/contributing/billing.md` at line 1, The "Contributing to Grida |
Billing" page lives in an unmaintained docs location; move the page titled
"Contributing to Grida | Billing" into an actively maintained docs subdirectory
(such as the project’s wg or reference docs area), update any
sidebars/navigation or cross-links that referenced the old location, and ensure
frontmatter/metadata (title, permalink) if present reflects the new location so
build/navigation continue to work.

Comment thread docs/platform/billing.mdx Outdated
Comment thread editor/app/(site)/organizations/[organization_name]/settings/billing/_view.tsx Outdated
Comment thread editor/lib/billing/__tests__/e2e/fixtures/org.ts Outdated
Comment thread supabase/schemas/grida_billing.sql
Comment thread supabase/schemas/grida_billing.sql Outdated
Comment thread supabase/tests/test_grida_billing_test.sql
Bug fixes:
- e2e teardown: drop early `return` on resource_missing — was skipping
  the local org delete and leaving orphaned fixtures
- fn_stamp_failure: UPSERT + p_event_type. The projector's stripe_event
  INSERT rolls back with the RAISE, so the prior UPDATE-only stamp
  silently matched nothing on first failure and the forensic record
  was lost. Caller passes event.type so the row can be inserted with
  required type metadata.

Defense-in-depth:
- Fail closed on stripe_customer_id drift in both fn_attach_stripe_customer
  and the customer.created/customer.updated projector branch. Mismatched
  ids now RAISE instead of silently no-oping; Stripe retries while ops
  fixes the binding.

UI polish:
- Button asChild around the upgrade Link (avoid invalid <button> in <a>)
- Plan-card "Current" badge now matches plan AND interval (was
  contradictory when toggling Annual on a Monthly sub)

Coverage:
- 20 new pgTAP assertions: SELECT/INSERT denial on each of the 5
  grida_billing internal tables (account, subscription, product_catalogue,
  stripe_event, audit) for both `anon` and `authenticated` roles.
  Catches future GRANT loosening or RESTRICTIVE policy drift.

Copy:
- Soften "the same minute" → "usually within a few minutes" on the
  payment-failure timing guarantee in docs/platform/billing.mdx

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🧹 Nitpick comments (1)
editor/lib/billing/__tests__/e2e/fixtures/org.ts (1)

71-75: 💤 Low value

Use Stripe error code property instead of message regex matching.

The current catch blocks use /No such subscription|resource_missing/i.test(msg) to detect "already gone" errors. Stripe errors expose a structured code property (e.g., 'resource_missing') and the stripe.errors.StripeError type — matching against the freeform message is fragile and could unintentionally match other errors containing those words. The official Stripe pattern is to check err instanceof stripe.errors.StripeError && err.code === "resource_missing".

♻️ Proposed changes
        const canceled = await stripe.subscriptions
          .cancel(sub.id)
          .catch((err: unknown) => {
            const msg = err instanceof Error ? err.message : String(err);
            if (/No such subscription|resource_missing/i.test(msg)) return null;
            throw err;
          });

Replace with:

        const canceled = await stripe.subscriptions
          .cancel(sub.id)
          .catch((err: unknown) => {
            if (
              err instanceof stripe.errors.StripeError &&
              err.code === "resource_missing"
            ) {
              return null;
            }
            throw err;
          });

And at lines 91–98:

    try {
      await stripe.customers.del(customerId);
    } catch (err) {
      const msg = err instanceof Error ? err.message : String(err);
      // "Already deleted" is fine — fall through to the local org delete.
      // Anything else is a real teardown failure and must not be silently
      // logged — orphan customers compound across runs and eventually trip
      // the test sandbox limits.
-     if (!/No such customer|resource_missing/i.test(msg)) {
-       throw new Error(`[e2e/org] customer delete failed: ${msg}`);
+     const isMissing =
+       err instanceof stripe.errors.StripeError &&
+       err.code === "resource_missing";
+     if (!isMissing) {
+       const msg = err instanceof Error ? err.message : String(err);
+       throw new Error(`[e2e/org] customer delete failed: ${msg}`);
       }
     }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@editor/lib/billing/__tests__/e2e/fixtures/org.ts` around lines 71 - 75,
Replace fragile regex message-matching in the promise rejection handler (the
.catch((err: unknown) => { ... }) block in the org fixture) with a structured
Stripe error check: detect Stripe errors using err instanceof
stripe.errors.StripeError and then check err.code for expected values (e.g.,
'resource_missing' and/or 'no_such_subscription') and return null for those
codes; otherwise rethrow the error. Ensure you reference the same catch block
and use the stripe.errors.StripeError type and err.code comparisons instead of
testing the freeform message.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@supabase/schemas/grida_billing.sql`:
- Around line 627-660: The audit INSERT's CASE diverges from the subscription
UPDATE logic for dispute events; compute the audit status using the same
predicates as the IF/ELSIF blocks (e.g., evaluate p_event_type and
v_dispute_status the same way those branches do) and use that computed value in
the INSERT instead of the current CASE — for example derive a v_audit_status
(using the same checks for 'charge.dispute.created', 'charge.dispute.updated'
with the pause-criteria, and 'charge.dispute.closed' with 'won' -> 'active' and
'lost' -> 'canceled' and 'warning_closed' -> 'active') and insert v_audit_status
so grida_billing.audit.status always matches the subscription updates performed
on grida_billing.subscription (refer to the IF/ELSIF blocks, v_dispute_status,
p_event_type, and the INSERT INTO grida_billing.audit).

---

Nitpick comments:
In `@editor/lib/billing/__tests__/e2e/fixtures/org.ts`:
- Around line 71-75: Replace fragile regex message-matching in the promise
rejection handler (the .catch((err: unknown) => { ... }) block in the org
fixture) with a structured Stripe error check: detect Stripe errors using err
instanceof stripe.errors.StripeError and then check err.code for expected values
(e.g., 'resource_missing' and/or 'no_such_subscription') and return null for
those codes; otherwise rethrow the error. Ensure you reference the same catch
block and use the stripe.errors.StripeError type and err.code comparisons
instead of testing the freeform message.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 93e7bdb0-e733-4392-ae36-69186822a075

📥 Commits

Reviewing files that changed from the base of the PR and between 0e13140 and 6605957.

📒 Files selected for processing (10)
  • database/database-generated.types.ts
  • docs/platform/billing.mdx
  • editor/app/(api)/private/webhooks/stripe/route.ts
  • editor/app/(site)/organizations/[organization_name]/settings/billing/_view.tsx
  • editor/app/(site)/organizations/[organization_name]/settings/billing/upgrade/_view.tsx
  • editor/lib/billing/__tests__/e2e/fixtures/org.ts
  • editor/lib/billing/index.ts
  • supabase/migrations/20260506132900_grida_billing.sql
  • supabase/schemas/grida_billing.sql
  • supabase/tests/test_grida_billing_test.sql
🚧 Files skipped from review as they are similar to previous changes (5)
  • editor/app/(api)/private/webhooks/stripe/route.ts
  • database/database-generated.types.ts
  • supabase/tests/test_grida_billing_test.sql
  • editor/lib/billing/index.ts
  • supabase/migrations/20260506132900_grida_billing.sql

Comment on lines +627 to +660
IF p_event_type = 'charge.dispute.created'
OR (p_event_type IN ('charge.dispute.updated')
AND v_dispute_status IN ('warning_needs_response','warning_under_review',
'needs_response','under_review')) THEN
UPDATE grida_billing.subscription
SET status = 'paused', updated_at = now()
WHERE stripe_subscription_id = v_sub_id
AND status NOT IN ('canceled');
ELSIF p_event_type = 'charge.dispute.closed'
AND v_dispute_status IN ('won','warning_closed') THEN
UPDATE grida_billing.subscription
SET status = 'active', updated_at = now()
WHERE stripe_subscription_id = v_sub_id
AND status = 'paused';
ELSIF p_event_type = 'charge.dispute.closed'
AND v_dispute_status = 'lost' THEN
UPDATE grida_billing.subscription
SET status = 'canceled', updated_at = now()
WHERE stripe_subscription_id = v_sub_id;
END IF;

INSERT INTO grida_billing.audit (
organization_id, operation, stripe_event_id, stripe_subscription_id,
event_type, status, note
) VALUES (
v_org_id, 'webhook.received', p_event_id, v_sub_id,
p_event_type,
CASE
WHEN p_event_type = 'charge.dispute.closed' AND v_dispute_status = 'won' THEN 'active'
WHEN p_event_type = 'charge.dispute.closed' AND v_dispute_status = 'lost' THEN 'canceled'
ELSE 'paused'
END,
format('dispute_id=%s status=%s', v_dispute_id, v_dispute_status)
);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Audit row status diverges from actual subscription status in two dispute paths.

The audit-row status CASE (lines 654-658) only encodes 'won' and 'lost' explicitly and falls through to 'paused' for everything else. That mis-stamps two real cases:

  1. charge.dispute.closed with v_dispute_status = 'warning_closed': the IF chain at lines 635-640 updates the subscription to status='active', but the audit row records 'paused'.
  2. charge.dispute.updated with any status outside the matched set (e.g. won, lost, warning_closed, charge_refunded): no UPDATE runs, but the audit still records 'paused' — claiming a transition that never happened.

Both make later forensic reconciliation against grida_billing.subscription.status unreliable.

🛠️ Suggested fix — derive the audit status from the same predicate as the UPDATE
-      INSERT INTO grida_billing.audit (
-        organization_id, operation, stripe_event_id, stripe_subscription_id,
-        event_type, status, note
-      ) VALUES (
-        v_org_id, 'webhook.received', p_event_id, v_sub_id,
-        p_event_type,
-        CASE
-          WHEN p_event_type = 'charge.dispute.closed' AND v_dispute_status = 'won'  THEN 'active'
-          WHEN p_event_type = 'charge.dispute.closed' AND v_dispute_status = 'lost' THEN 'canceled'
-          ELSE 'paused'
-        END,
-        format('dispute_id=%s status=%s', v_dispute_id, v_dispute_status)
-      );
+      INSERT INTO grida_billing.audit (
+        organization_id, operation, stripe_event_id, stripe_subscription_id,
+        event_type, status, note
+      ) VALUES (
+        v_org_id, 'webhook.received', p_event_id, v_sub_id,
+        p_event_type,
+        CASE
+          WHEN p_event_type = 'charge.dispute.closed'
+               AND v_dispute_status IN ('won','warning_closed') THEN 'active'
+          WHEN p_event_type = 'charge.dispute.closed'
+               AND v_dispute_status = 'lost'                    THEN 'canceled'
+          WHEN p_event_type = 'charge.dispute.created'
+            OR (p_event_type = 'charge.dispute.updated'
+                AND v_dispute_status IN ('warning_needs_response','warning_under_review',
+                                         'needs_response','under_review'))            THEN 'paused'
+          ELSE NULL  -- no-op branch (e.g. dispute.updated → final status)
+        END,
+        format('dispute_id=%s status=%s', v_dispute_id, v_dispute_status)
+      );
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@supabase/schemas/grida_billing.sql` around lines 627 - 660, The audit
INSERT's CASE diverges from the subscription UPDATE logic for dispute events;
compute the audit status using the same predicates as the IF/ELSIF blocks (e.g.,
evaluate p_event_type and v_dispute_status the same way those branches do) and
use that computed value in the INSERT instead of the current CASE — for example
derive a v_audit_status (using the same checks for 'charge.dispute.created',
'charge.dispute.updated' with the pause-criteria, and 'charge.dispute.closed'
with 'won' -> 'active' and 'lost' -> 'canceled' and 'warning_closed' ->
'active') and insert v_audit_status so grida_billing.audit.status always matches
the subscription updates performed on grida_billing.subscription (refer to the
IF/ELSIF blocks, v_dispute_status, p_event_type, and the INSERT INTO
grida_billing.audit).

@softmarshmallow softmarshmallow merged commit 6e6617e into main May 7, 2026
15 checks passed
@softmarshmallow softmarshmallow added enterprise ee enterprise edition stripe and removed enterprise labels May 7, 2026
@softmarshmallow

Copy link
Copy Markdown
Member Author

Follow-up: #708 (production backfill for pre-migration orgs + drop dev test-card hint from upgrade page).

softmarshmallow added a commit that referenced this pull request May 7, 2026
…d-cleanups

fix(billing): post-#705 follow-ups — backfill + cleanup
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

ee enterprise edition stripe

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant